SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
7 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
25.0 KB · 446 lines tsx
Raw Blame History
1import type { FacilityStatus, MetroDetail } from "@dci/core";2import { ExternalLink } from "lucide-react";3import type { Metadata } from "next";4import Link from "next/link";5import { notFound } from "next/navigation";6import { Breadcrumbs } from "@/components/entity/breadcrumbs";7import { EntityMap } from "@/components/entity/entity-map";8import { JsonLd } from "@/components/entity/json-ld";9import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";10import { SectionNav } from "@/components/entity/section-nav";11import { SourcesFooter } from "@/components/entity/sources-footer";12import { EventList } from "@/components/event-list";13import { ClaimsTable } from "@/components/evidence";14import { FacilityTable } from "@/components/facility-table";15import { MarketConcentrationPanel } from "@/components/intelligence/market/concentration";16import { MarketMomentumPanel } from "@/components/intelligence/market/momentum";17import { OpeningTimeline } from "@/components/intelligence/market/opening-timeline";18import { ApiNote, CoverageBar, CoverageStrip, GridConstraintList, HonestEmpty, PillLink, PipelineBars, PublishedOnlyNote, ShareList } from "@/components/intelligence/shared";19import { WatchButton } from "@/components/intelligence/watch-button";20import { ProjectList } from "@/components/project-list";21import { Badge, EstimateMark } from "@/components/ui/badge";22import { Page, Section } from "@/components/ui/card";23import { StackedBar } from "@/components/ui/charts";24import { CountryFlag } from "@/components/ui/country-flag";25import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";26import { ErrorState, OfflineNotice } from "@/components/ui/states";27import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";28import type { DetailFacilitiesMeta } from "@/lib/dto";29import { fmtDate, fmtInt, fmtMw, fmtPct, pluralize, splitMw } from "@/lib/format";30import { routes } from "@/lib/routes";31import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";32import type { SearchParams } from "@/lib/sort";33import { countryHref, countrySlugMap, pipelineMw } from "../country-links";34import { getMetro } from "./data";35import { GrowthChart } from "./growth-chart";3637export const revalidate = 300;3839type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };4041function describe(m: MetroDetail): string {42  const parts = [`${fmtInt(m.facilityCount)} data center ${m.facilityCount === 1 ? "facility" : "facilities"} in ${m.name}${m.countryName ? `, ${m.countryName}` : ""}`];43  if (m.knownMw !== null) parts.push(`${fmtMw(m.knownMw)} known operational capacity (${fmtPct(m.mwCoverage)} of facilities publish a figure)`);44  const pipe = pipelineMw(m);45  if (pipe !== null) parts.push(`${fmtMw(pipe)} in the pipeline`);46  parts.push(`${fmtInt(m.operatorCount)} operators`);47  if (m.cloudRegionCount) parts.push(`${fmtInt(m.cloudRegionCount)} cloud regions`);48  return `${parts.join(", ")}. Operators, concentration, momentum, pipeline, connectivity, power context and coverage for the ${m.name} market — every figure sourced.`;49}5051export async function generateMetadata({ params }: Props): Promise<Metadata> {52  const { slug } = await params;53  const r = await getMetro(slug);54  if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Market" };55  return pageMetadata({ title: `Data Centers in ${r.data.name}`, description: describe(r.data), path: routes.market(r.data.slug) });56}5758const CONSTRAINT_LABEL: Record<MetroDetail["constraints"][number]["kind"], string> = { power: "Power", land: "Land", water: "Water", moratorium: "Moratorium", grid: "Grid" };5960function Mw({ mw, estimate }: { mw: number | null | undefined; estimate?: boolean }) {61  const s = splitMw(mw);62  if (!s) return <span className="text-ink-4">—</span>;63  return (64    <>65      {estimate && <EstimateMark className="mr-0.5 text-[0.6em]" />}66      {s.value}67    </>68  );69}70const unitOf = (mw: number | null | undefined) => splitMw(mw)?.unit ?? undefined;7172export default async function MarketPage({ params, searchParams }: Props) {73  const [{ slug }, sp] = await Promise.all([params, searchParams]);74  const fPage = parseFPage(sp.fPage);75  const [r, { map }] = await Promise.all([getMetro(slug, fPage), countrySlugMap()]);76  if (!r.ok && r.status === 404) notFound();77  if (!r.ok) {78    return (79      <Page className="py-6">80        <OfflineNotice detail={r.error} className="mb-4" />81        <ErrorState />82      </Page>83    );84  }85  const m = r.data;86  const meta = (r.meta ?? {}) as DetailFacilitiesMeta;87  const facTotal = meta.facilitiesTotal ?? m.facilityCount;88  const facPage = meta.facilitiesPage ?? fPage;89  const facPer = meta.facilitiesPerPage ?? 50;90  const pipe = pipelineMw(m);91  const cHref = countryHref(map, m.countryIso2);92  const base = routes.market(m.slug);93  const aiFacilities = m.aiFacilities ?? [];94  const gridConstraints = m.gridConstraints ?? [];95  const claims = m.claims ?? [];96  const openingTimeline = m.openingTimeline ?? [];97  const aiCount = m.aiCount ?? aiFacilities.length;9899  const statusData: Partial<Record<FacilityStatus, number>> = {};100  if (m.operationalCount) statusData.operational = m.operationalCount;101  if (m.constructionCount) statusData.under_construction = m.constructionCount;102  if (m.plannedCount) statusData.announced = m.plannedCount;103  const other = m.facilityCount - m.operationalCount - m.constructionCount - m.plannedCount;104  if (other > 0) statusData.unknown = other;105106  const hasCloud = m.cloudProviders.length > 0 || m.cloudRegions.length > 0;107  const powerItems = gridConstraints.length + m.constraints.length;108  const sections = [109    { id: "map", label: "Map" },110    { id: "status", label: "Status" },111    { id: "operators", label: "Operators", count: m.operators.length },112    { id: "pipeline", label: "Pipeline" },113    { id: "openings", label: "Openings" },114    { id: "concentration", label: "Concentration" },115    { id: "momentum", label: "Momentum" },116    { id: "connectivity", label: "Connectivity" },117    { id: "power", label: "Power" },118    { id: "coverage", label: "Coverage" },119    { id: "ai", label: "AI", count: aiFacilities.length },120    { id: "projects", label: "Projects", count: m.projects.length },121    { id: "changes", label: "Changes", count: m.recentEvents.length },122    { id: "facilities", label: "Facilities", count: facTotal },123    { id: "claims", label: "Claims", count: claims.length },124  ];125126  const jsonLd = compactJsonLd({127    "@context": "https://schema.org",128    "@type": "Place",129    name: `${m.name} data center market`,130    url: `${routes.market(m.slug)}`,131    address: { "@type": "PostalAddress", addressLocality: m.name, addressRegion: m.regionName ?? undefined, addressCountry: m.countryIso2 },132    geo: { "@type": "GeoCoordinates", latitude: m.lat, longitude: m.lng },133    description: describe(m),134  });135136  const topOps = [...m.operators].sort((a, b) => b.facilityCount - a.facilityCount).slice(0, 15);137  const opsTotalFac = m.facilityCount || 1;138139  return (140    <Page>141      <Breadcrumbs items={[{ name: "Data centers", href: routes.datacenters() }, { name: "Markets", href: routes.markets() }, { name: m.name, href: base }]} className="pt-3" />142      <JsonLd data={jsonLd} />143144      <header className="py-5 sm:py-7">145        <div className="flex flex-wrap items-start justify-between gap-3">146          <div className="min-w-0">147            <div className="label-caps mb-1.5">Market</div>148            <h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[28px]">Data centers in {m.name}</h1>149            <div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-ink-2">150              <CountryFlag iso2={m.countryIso2} size={13} />151              {cHref ? (152                <Link href={cHref} className="text-ink hover:text-accent hover:underline">153                  {m.countryName ?? m.countryIso2}154                </Link>155              ) : (156                <span>{m.countryName ?? m.countryIso2}</span>157              )}158              {m.regionName && m.regionName !== m.name && (159                <>160                  <span aria-hidden className="text-ink-4">161                    ·162                  </span>163                  <span className="text-ink-3">{m.regionName}</span>164                </>165              )}166              <span aria-hidden className="text-ink-4">167                ·168              </span>169              <span className="figure text-[12px] text-ink-3">170                {m.lat.toFixed(2)}, {m.lng.toFixed(2)}171              </span>172            </div>173          </div>174          <div className="flex flex-wrap items-center gap-2">175            <WatchButton entityType="metro" slug={m.slug} entityId={m.id} name={m.name} />176            <PillLink href={routes.explore(`entity=facilities&metro=${m.slug}`)}>Explore →</PillLink>177          </div>178        </div>179        {m.description && <p className="mt-2 max-w-2xl text-[13.5px] text-ink-2">{m.description}</p>}180        {m.aliases.length > 0 && (181          <div className="mt-3">182            <div className="label-caps mb-1.5">Sub-markets</div>183            <div className="flex flex-wrap gap-1">184              {m.aliases.slice(0, 8).map((a) => (185                <Badge key={a} tone="outline" size="xs">186                  {a}187                </Badge>188              ))}189              {m.aliases.length > 8 && (190                <details className="inline">191                  <summary className="inline-flex h-[18px] cursor-pointer list-none items-center rounded-xs border border-hair-2 px-1.5 text-[10.5px] font-medium text-ink-3 hover:text-ink [&::-webkit-details-marker]:hidden">+{m.aliases.length - 8} more</summary>192                  <span className="mt-1 flex flex-wrap gap-1">193                    {m.aliases.slice(8).map((a) => (194                      <Badge key={a} tone="outline" size="xs">195                        {a}196                      </Badge>197                    ))}198                  </span>199                </details>200              )}201            </div>202          </div>203        )}204      </header>205206      <MetricRow cols={4}>207        <MetricCell>208          <Metric value={fmtInt(m.facilityCount)} label="Facilities" note={`${fmtInt(m.operationalCount)} operational`} size="sm" />209        </MetricCell>210        <MetricCell>211          <Metric value={fmtInt(m.operatorCount)} label="Operators" size="sm" />212        </MetricCell>213        <MetricCell>214          <Metric value={<Mw mw={m.knownMw} />} unit={unitOf(m.knownMw)} label="Known MW" note={<CoverageBar value={m.mwCoverage ?? null} label="MW coverage" width="w-full max-w-[140px]" />} size="sm" />215        </MetricCell>216        <MetricCell>217          <Metric value={<Mw mw={pipe} estimate />} unit={unitOf(pipe)} label="Pipeline MW" note={pipe === null ? `${fmtInt(m.constructionCount + m.plannedCount)} ${pluralize(m.constructionCount + m.plannedCount, "site")}, MW not published` : `${fmtInt(m.constructionCount)} under construction · ${fmtInt(m.plannedCount)} planned`} size="sm" />218        </MetricCell>219        <MetricCell>220          <Metric value={fmtInt(m.cloudRegionCount)} label="Cloud regions" size="sm" />221        </MetricCell>222        <MetricCell>223          <Metric value={fmtInt(m.ixpCount)} label="IXPs" size="sm" />224        </MetricCell>225        <MetricCell>226          <Metric value={fmtInt(aiCount)} label="AI / HPC sites" note="confirmed + likely evidence" size="sm" />227        </MetricCell>228        <MetricCell>229          <Metric value={fmtInt(m.projectCount)} label="Projects" note={m.projectPlannedMw ? `≈ ${fmtMw(m.projectPlannedMw)} planned` : undefined} size="sm" />230        </MetricCell>231      </MetricRow>232233      {m.rankings.length > 0 && (234        <div className="-mx-3 flex gap-1.5 overflow-x-auto px-3 py-3 no-scrollbar sm:mx-0 sm:flex-wrap sm:px-0" aria-label="Ranking positions">235          {m.rankings.map((rk) => (236            <Link key={rk.key} href={routes.ranking(rk.key)} className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12px] text-ink-2 hover:border-hair-2 hover:text-ink sm:h-7">237              <span className="figure font-medium text-ink">#{rk.rank}</span>238              <span className="figure text-ink-4">/ {rk.total}</span>239              <span>{rk.label}</span>240            </Link>241          ))}242        </div>243      )}244245      <SectionNav items={sections} className="mt-2" />246247      <div className="flex flex-col gap-8 pt-6">248        <Section id="map" title="Map" description="Facilities in and around the market; the view opens the interactive map filtered to the country." className="scroll-mt-28">249          <EntityMap center={{ lat: m.lat, lng: m.lng }} zoom={9} filters={{ country: m.countryIso2 }} openLabel={`Open ${m.name} in map`} className="h-[240px] sm:h-[360px]" />250        </Section>251252        <Section id="status" title="Status" description="Facilities by lifecycle status." className="scroll-mt-28">253          <StackedBar data={statusData} total={m.facilityCount} />254        </Section>255256        <Section id="operators" title="Top operators" description="By facilities operated in this market; share of the market's facility count." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(m.operatorCount)} total</span>}>257          {topOps.length ? <ShareList items={topOps.map((o) => ({ key: o.id, label: o.name, href: routes.operator(o.slug), count: o.facilityCount, share: o.facilityCount / opsTotalFac, mw: o.knownMw ?? null }))} unit="facilities" /> : <HonestEmpty title="No operator attributed yet" />}258        </Section>259260        <Section id="pipeline" title="Construction pipeline" description="Facilities by lifecycle bucket with the MW they publish, then project records by stage." className="scroll-mt-28" aside={<Link href={routes.projects(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link>}>261          {m.pipeline ? <PipelineBars pipeline={m.pipeline} /> : <HonestEmpty title="Pipeline breakdown unavailable" />}262          <PublishedOnlyNote className="mt-3" />263        </Section>264265        <Section id="openings" title="Opening timeline" description="Facilities opened per published opening year." className="scroll-mt-28">266          {openingTimeline.length ? (267            <OpeningTimeline data={openingTimeline} />268          ) : (269            <HonestEmpty title="No published opening dates for this market" detail="operators and registries indexed here have not published when their facilities opened; nothing is estimated" />270          )}271          {m.growth.length >= 2 && (272            <div className="mt-5">273              <div className="label-caps mb-2">Cumulative growth</div>274              <GrowthChart data={m.growth} />275            </div>276          )}277        </Section>278279        <Section id="concentration" title="Market concentration" description="Herfindahl–Hirschman index and top-operator shares — on facility counts and, separately, on known MW." className="scroll-mt-28">280          {m.concentration ? <MarketConcentrationPanel c={m.concentration} facilityCount={m.facilityCount} /> : <HonestEmpty title="Concentration not computed" />}281        </Section>282283        <Section id="momentum" title="Market momentum" description="Separate, transparent components over the last 12 months. There is no composite score: each count stands on its own and links to its evidence." className="scroll-mt-28">284          {m.momentum ? <MarketMomentumPanel m={m.momentum} metroSlug={m.slug} countryIso2={m.countryIso2} /> : <HonestEmpty title="Momentum not computed" />}285        </Section>286287        <Section id="connectivity" title="Connectivity" description="Public cloud regions associated with this market and internet exchanges present in its facilities." className="scroll-mt-28" aside={<Link href={routes.connectivity()} className="text-[12px] text-ink-3 hover:text-ink">Connectivity index →</Link>}>288          {hasCloud ? (289            <div className="mb-5">290              <div className="label-caps mb-2">Cloud</div>291              {m.cloudProviders.length > 0 && (292                <ul className="mb-3 flex flex-wrap gap-1.5">293                  {m.cloudProviders.map((p) => (294                    <li key={p.id}>295                      <Link href={routes.operator(p.slug)} className="inline-flex h-10 items-center gap-1.5 rounded-xs border border-hair bg-surface px-2 text-[12.5px] text-ink hover:border-hair-2 hover:text-accent sm:h-8">296                        {p.name}297                        <span className="figure text-[11px] text-ink-3">298                          {p.regionCount} {p.regionCount === 1 ? "region" : "regions"}299                        </span>300                      </Link>301                    </li>302                  ))}303                </ul>304              )}305              {m.cloudRegions.length > 0 && (306                <Table dense mobileCollapse={{ total: m.cloudRegions.length, label: "cloud regions" }}>307                  <THead>308                    <tr>309                      <Th>Region</Th>310                      <Th className="hidden sm:table-cell">Provider</Th>311                      <Th className="hidden md:table-cell">Code</Th>312                      <Th numeric>AZs</Th>313                      <Th>Status</Th>314                      <Th className="hidden lg:table-cell">Launched</Th>315                    </tr>316                  </THead>317                  <TBody>318                    {m.cloudRegions.map((cr) => (319                      <Tr key={cr.id}>320                        <Td primary>321                          <RowLink href={routes.cloudRegion(cr.slug)}>{cr.name}</RowLink>322                          <div className="text-[11.5px] text-ink-3 sm:hidden">323                            {cr.provider.name} · <span className="figure">{cr.code}</span>324                          </div>325                        </Td>326                        <Td label="Provider" className="hidden sm:table-cell">327                          <Link href={routes.operator(cr.provider.slug)} className="text-ink-2 hover:text-ink hover:underline">328                            {cr.provider.name}329                          </Link>330                        </Td>331                        <Td label="Code" className="figure hidden text-[12px] md:table-cell">332                          {cr.code}333                        </Td>334                        <Td label="AZs" numeric>335                          {cr.availabilityZones ?? <span className="text-ink-4">—</span>}336                        </Td>337                        <Td label="Status">338                          <span className="inline-flex flex-wrap gap-1">339                            <Badge tone={cr.status === "operational" ? "ok" : cr.status === "announced" ? "info" : "neutral"} size="xs">340                              {cr.status}341                            </Badge>342                            {cr.isSovereign && (343                              <Badge tone="outline" size="xs" title="Sovereign cloud region">344                                Sovereign345                              </Badge>346                            )}347                          </span>348                        </Td>349                        <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">350                          {fmtDate(cr.launchedOn)}351                        </Td>352                      </Tr>353                    ))}354                  </TBody>355                </Table>356              )}357              <p className="mt-1.5 text-[11px] text-ink-4">Cloud regions are associated with the market, not hosted at a specific facility unless a source says so.</p>358            </div>359          ) : (360            <HonestEmpty title="No public cloud region associated with this market" className="mb-5" />361          )}362          <div className="label-caps mb-2">Internet exchanges</div>363          {m.ixps.length ? (364            <ul className="divide-y divide-hair">365              {m.ixps.map((x) => (366                <li key={x.id} className="flex items-baseline justify-between gap-3 py-2">367                  <Link href={routes.ixp(x.slug)} className="text-[13.5px] font-medium text-ink hover:text-accent hover:underline">368                    {x.name}369                  </Link>370                  <span className="figure shrink-0 text-[12px] text-ink-3">{x.networkCount === null ? "networks —" : `${fmtInt(x.networkCount)} networks`}</span>371                </li>372              ))}373            </ul>374          ) : (375            <HonestEmpty title="No IXP presence recorded in this market" detail="IXP presence comes from published IXP ↔ facility links; none is inferred" />376          )}377        </Section>378379        <Section id="power" title="Power context" description="Grid constraints, utility events and power agreements affecting this market." className="scroll-mt-28" aside={<Link href={routes.power()} className="text-[12px] text-ink-3 hover:text-ink">Power & grid →</Link>}>380          <GridConstraintList items={gridConstraints} showPlace={false} />381          {m.constraints.length > 0 && (382            <div className="mt-4">383              <div className="label-caps mb-1.5">Constraint mentions in indexed sources</div>384              <ul className="divide-y divide-hair">385                {m.constraints.map((c, i) => (386                  <li key={i} className="py-2.5">387                    <div className="flex flex-wrap items-center gap-2">388                      <Badge tone={c.kind === "moratorium" || c.kind === "power" ? "warn" : "outline"} size="xs">389                        {CONSTRAINT_LABEL[c.kind]}390                      </Badge>391                      {c.date && (392                        <time className="figure text-[11.5px] text-ink-3" dateTime={c.date}>393                          {fmtDate(c.date)}394                        </time>395                      )}396                    </div>397                    <p className="mt-1 text-[13px] text-ink-2">{c.summary}</p>398                    <a href={c.url} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex min-h-6 items-center gap-1 text-[11.5px] text-ink-3 hover:text-accent">399                      {c.sourceName} <ExternalLink className="size-3" />400                    </a>401                  </li>402                ))}403              </ul>404            </div>405          )}406          <ApiNote label="Reading" className="mt-3" note={`Observed events and curated public reports only — nothing is inferred${powerItems === 0 ? "; the absence of items means no report was indexed, not that the grid is unconstrained" : ""}. Utility and grid MW are supply-side figures, never IT load.`} />407        </Section>408409        <Section id="coverage" title="Coverage" description="How much of this market the index actually knows — shown so the figures above can be read honestly." className="scroll-mt-28" aside={<Link href={routes.coverage()} className="text-[12px] text-ink-3 hover:text-ink">Coverage report →</Link>}>410          {m.coverage ? <CoverageStrip row={m.coverage} /> : <HonestEmpty title="Coverage not computed" />}411        </Section>412413        {aiFacilities.length > 0 && (414          <Section id="ai" title="AI / HPC facilities" description="Facilities with confirmed or likely AI evidence in this market." className="scroll-mt-28" aside={<Link href={routes.ai()} className="text-[12px] text-ink-3 hover:text-ink">AI infrastructure →</Link>}>415            <FacilityTable items={aiFacilities} columns={["operator", "status", "mw", "type"]} />416          </Section>417        )}418419        {m.projects.length > 0 && (420          <Section id="projects" title="Projects" description="Announced, permitted and under-construction projects linked to this market." className="scroll-mt-28" aside={<Link href={routes.projects(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link>}>421            <ProjectList items={m.projects} />422          </Section>423        )}424425        {m.recentEvents.length > 0 && (426          <Section id="changes" title="Recent changes" description="Detected changes on facilities and projects in this market." className="scroll-mt-28" aside={<Link href={routes.live(`metro=${m.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">Live feed →</Link>}>427            <EventList events={m.recentEvents} dense />428          </Section>429        )}430431        <Section id="facilities" title="Facilities" description="Sorted by best known capacity (IT, total or ≈ planned)." className="scroll-mt-28" aside={<span className="figure text-[12px] text-ink-3">{fmtInt(facTotal)}</span>}>432          <PagedFacilities items={m.facilities} total={facTotal} page={facPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["operator", "status", "mw", "type", "confidence"]} emptyTitle="No facilities indexed in this market yet" />433        </Section>434435        {claims.length > 0 && (436          <Section id="claims" title="Claims about this market" description="Figures asserted about the market itself (metro-scoped) — shown as evidence, never summed into facility totals." className="scroll-mt-28">437            <ClaimsTable claims={claims} kind="all" />438          </Section>439        )}440      </div>441442      <SourcesFooter sources={r.sources} note={pipe === null && m.knownMw === null ? "No MW figure has been published by the sources indexed for this market; counts are exact, capacities are shown only when a source publishes them." : undefined} />443    </Page>444  );445}446